<?php
/*
 * Copyright 2010 Johannes M. Schmitt
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * This class is used to generate the css renaming map for the given css files.
 * 
 * @author Johannes M. Schmitt <schmittjoh@gmail.com>
 */
class GoogleClosureCSSRenamingMap
{
	/**
	 * Characters that might appear in the generated class name as first character
	 * @var string
	 */
	const FIRST_CHARS = 'abcdefghijklmnopqrstuvwxyz';
	
	/**
	 * Characters that might appear in the generated class name as any but the first character
	 * @var string
	 */
  const NON_FIRST_CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789-_';
	
	/**
	 * Regex for matching css classes
	 * @var string
	 */
	const REGEX_CSS_CLASS = '#(?<=\.)(?:-(?![0-9]))?(?:[_a-zA-Z]|[\200-\377]|\\[0-9a-f]{1,6}(?:\r\n|[ \t\r\n\f])?|\\[^\r\n\f0-9a-f])(?:[_a-zA-Z0-9-]|[\200-\377]|\\[0-9a-f]{1,6}(?:\r\n|[ \t\r\n\f])?|\\[^\r\n\f0-9a-f])*#';
	
	/**
	 * Regex for matching urls in CSS
	 * @var string
	 */
	const REGEX_URL = '#url\(([^)]+)\)#';
	
	/**
	 * An array of files which a class renaming map is created for
	 * @var array
	 */
	private $_cssFiles;
	
	/**
	 * An array of the css files' contents
	 * @var array
	 */
	private $_cssContents;
	
	/**
	 * A map of old classes to new classes
	 * @var array
	 */
	private $_renamingMap;
	
	/**
	 * A map of existing classes and their number of occurence
	 * @var array
	 */
	private $_classNames;
	
	/**
	 * Whether the map is already compiled
	 * @var boolean
	 */
	private $_isCompiled;
	
	/**
	 * Only used internally
	 * @var integer
	 */
	private $_classCount;
	
	/**
	 * A prefix to prepend to all generated variable names
	 * @var string
	 */
	private $_namePrefix;
	
	/**
	 * A prefix to prepend to all generated names 
	 * @param string $prefix
	 */
	public final function __construct($prefix = '')
	{
		$this->_namePrefix = $prefix;
		$this->_cssFiles = array();
		$this->_cssContents = array();
		$this->_renamingMap = array();
		$this->_classNames = array();
		$this->_isCompiled = false;
	}
	
	/**
	 * Adds multiple files
	 * @param array $cssFiles
	 */
	public final function addFiles(array $cssFiles)
	{
		foreach ($cssFiles as $file)
		  $this->addFile($file);
	}
	
	/**
	 * Adds a single file
	 * @throws InvalidArgumentException if the file is not found, is
	 *         unreadable, or has already been added
	 * @param string $cssFile
	 */
	public final function addFile($cssFile)
	{
		if (!file_exists($cssFile) || !is_readable($cssFile))
		  throw new InvalidArgumentException(sprintf(
		    'The file "%s" could not be found, or is unreadable.', $cssFile));
		  
		if (in_array($cssFile, $this->_cssFiles, true))
		  throw new InvalidArgumentException(sprintf(
		    'The file "%s" has already been added.', $cssFile));
		  
		$this->_cssFiles[] = $cssFile;
		$this->_isCompiled = false;
	}
	
	/**
	 * Returns the CSS renaming map
	 * @return array
	 */
	public final function getRenamingMap()
	{
		$this->compile();
		return $this->_renamingMap;
	}
	
	/**
	 * Whether the renaming map has been compiled
	 * @return boolean
	 */
	public final function isCompiled()
	{
		return $this->_isCompiled;
	}
	
	/**
	 * Returns an array of CSS files to process
	 * @return array
	 */
	public final function getFiles()
	{
		return $this->_cssFiles;
	}
	
	/**
	 * Returns an array of class names and their number of occurence
	 * @return array
	 */
	public final function getClassNames()
	{
		$this->compile();
		return $this->_classNames;
	}
	
	/**
	 * Returns an array of the css contents of the given files
	 * @return array
	 */
	public final function getCSSContents()
	{
		$this->compile();
		return $this->_cssContents;
	}
	
	/**
	 * Compiles the renaming map if it does not yet exist
	 */
	public final function compile()
	{
		if ($this->_isCompiled)
		  return;
		$this->_isCompiled = true;
		
		// get all class names
		$classNames = array();
		$urlMap = array();
		$cssContents = array();
		foreach ($this->_cssFiles as $filePath)
		{
			$css = file_get_contents($filePath);
			
			// strip comments
			// TODO: This likely breaks browser-dependent comment hacks
			// http://www.webdevout.net/css-hacks#in_css-selectors
			// http://css-discuss.incutio.com/?page=BoxModelHack
			// http://www.crankygeek.com/ie6pebug/
			// http://tantek.com/CSS/Examples/midpass.html
			$css = preg_replace('#/\*(?:[^*]|\*(?!/))*\*/#s', '', $css);
			
			// collapse urls as they produce false positives
			$css = preg_replace_callback(self::REGEX_URL, 
			  function($matches) use ($urlMap) {
			  	list(, $url) = $matches;
			  	
			  	do 
			  	{
			  		$placeHolder = sha1(uniqid());
			  	}
			  	while (isset($urlMap[$placeHolder]));
			  	
			  	$urlMap[$placeHolder] = $url;
			  	
			  	return $placeHolder;
			  }, $css);
			$cssContents[$filePath] = $css;
			
			// get all used class names
			if (preg_match_all(self::REGEX_CSS_CLASS, $css, $matches))
	    {
	    	foreach ($matches[0] as $class)
	    	{
	    		if (!isset($classNames[$class]))
	    		  $classNames[$class] = 0;
	    		
	    		$classNames[$class] += 1;
	    	}
	    }
		}
		
		$this->_classNames = $classNames;
		
		// build the renaming map
		arsort($classNames);
		$this->_classCount = 0;
		$renamingMap = array();
		foreach ($classNames as $name => $occurence)
		{
			$newName = $this->_getNextName();
			// if the original name is shorter, keep it
			if (strlen($name) < strlen($newName))
			  $newName = $name;
			  
			$renamingMap[$name] = $newName;
		}
		$this->_renamingMap = $renamingMap;
		
		// now replace the contents of the css files with the short names
		foreach ($cssContents as $filePath => $content)
		{
			$content = preg_replace_callback(self::REGEX_CSS_CLASS,
			  function($matches) use($renamingMap) 
			  {
			  	list($class) = $matches;
			  	return $renamingMap[$class];
			  }, $content);
			  
			$content = preg_replace_callback(self::REGEX_URL,
			  function($matches) use ($urlMap)
			  {
			  	list($url) = $matches;
			  	return $urlMap[$url];
			  }, $content);
			  
			$cssContents[$filePath] = $content;
		}
		$this->_cssContents = $cssContents;
	}
	
	/**
	 * Returns the next name to use
	 * @return string
	 * TODO: This should be moved to an own class
	 */
	private function _getNextName()
	{
		$firstChars = self::FIRST_CHARS;
		$firstCharsLength = strlen($firstChars);
		$nonFirstChars = self::NON_FIRST_CHARS;
		$nonFirstCharsLength = strlen($nonFirstChars);
		
		while (true)
		{
			$name = $this->_namePrefix;
			$i = $this->_classCount;
			
			if ($name === '')
			{
				$name .= $firstChars[$i%$firstCharsLength];
				$i = intval($i/$firstCharsLength);
			}
			
			while ($i > 0)
			{
				$i -= 1;
				$name .= $nonFirstChars[$i%$nonFirstCharsLength];
				$i = intval($i/$nonFirstCharsLength);
			}
		
			$this->_classCount += 1;
			
			// check that the name is not reserved
			if (isset($this->_classNames[$name]))
			  continue;
						
  		return $name;
		}
	}
}